Анализ изменения выручки интернет-магазина по результатам A/B-теста

Описание проекта

Приоритизируем гипотезы для увеличения выручки интернет-магазина, запустим A/B-тест и проанализируем результаты.

Оглавление

In [1]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import plotly.express as px
import seaborn as sns
import math
from IPython.display import display
from scipy import stats as st

Часть 1. Приоритизация гипотез

Загрузим датасет hypothesis и выведем на экран.

In [2]:
hypothesis = pd.read_csv('/datasets/hypothesis.csv')
In [3]:
hypothesis
Out[3]:
Hypothesis Reach Impact Confidence Efforts
0 Добавить два новых канала привлечения трафика,... 3 10 8 6
1 Запустить собственную службу доставки, что сок... 2 5 4 10
2 Добавить блоки рекомендаций товаров на сайт ин... 8 3 7 3
3 Изменить структура категорий, что увеличит кон... 8 3 3 8
4 Изменить цвет фона главной страницы, чтобы уве... 3 1 1 1
5 Добавить страницу отзывов клиентов о магазине,... 3 2 2 3
6 Показать на главной странице баннеры с актуаль... 5 3 8 3
7 Добавить форму подписки на все основные страни... 10 7 8 5
8 Запустить акцию, дающую скидку на товар в день... 1 9 9 5

Применим фреймворк ICE для приоритизации гипотез. Отсортируем их по убыванию приоритета и построим гистограмму.

In [4]:
hypothesis['ICE'] = hypothesis['Impact'] * hypothesis['Confidence'] / hypothesis['Efforts']
In [5]:
hypothesis_ICE = hypothesis.sort_values(by='ICE')[['Hypothesis', 'ICE']]
fig = px.bar(hypothesis_ICE, x='ICE', y='Hypothesis', orientation='h', labels={'ICE': 'ICE', 'Hypothesis':'Гипотеза'})
fig.update_layout(title_text='Приоритизация гипотез по фреймворку ICE', yaxis_showticklabels=False)
fig.show()

Применим фреймворк RICE для приоритизации гипотез. Отсортируем их по убыванию приоритета и построим гистограмму.

In [6]:
hypothesis['RICE'] = hypothesis['Reach'] * hypothesis['Impact'] * hypothesis['Confidence'] / hypothesis['Efforts']
In [7]:
hypothesis_RICE = hypothesis.sort_values(by='RICE')[['Hypothesis', 'RICE']]
fig = px.bar(hypothesis_RICE, x='RICE', y='Hypothesis', orientation='h', labels={'RICE': 'RICE', 'Hypothesis':'Гипотеза'})
fig.update_layout(title_text='Приоритизация гипотез по фреймворку RICE', yaxis_showticklabels=False)
fig.show()

При применении RICE вместо ICE с третьего места на первое вышла гипотеза "Добавить форму подписки на все основные страницы, чтобы собрать базу клиентов для email-рассылок", потому что она с достаточной силой повлияет на всех пользователей. Гипотеза "Запустить акцию, дающую скидку на товар в день рождения" опустилась на пятое место из-за низкого охвата: чтобы затронуть всех пользователей потребуется целый год.

Часть 2. Анализ A/B-теста

Загрузим данные и изучим общую информацию.

In [8]:
orders = pd.read_csv('/datasets/orders.csv')
In [9]:
orders.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1197 entries, 0 to 1196
Data columns (total 5 columns):
transactionId    1197 non-null int64
visitorId        1197 non-null int64
date             1197 non-null object
revenue          1197 non-null int64
group            1197 non-null object
dtypes: int64(3), object(2)
memory usage: 46.9+ KB
In [10]:
visitors = pd.read_csv('/datasets/visitors.csv')
In [11]:
visitors.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 62 entries, 0 to 61
Data columns (total 3 columns):
date        62 non-null object
group       62 non-null object
visitors    62 non-null int64
dtypes: int64(1), object(2)
memory usage: 1.6+ KB

Преобразуем строки в столбце date таблицы orders в формат даты.

In [12]:
orders['date'] = pd.to_datetime(orders['date'], format='%Y-%m-%d')

Преобразуем строки в столбце date таблицы visitors в формат даты.

In [13]:
visitors['date'] = pd.to_datetime(visitors['date'], format='%Y-%m-%d')
In [14]:
visitors_intersection = np.intersect1d(
    orders[orders['group'] == 'A']['visitorId'], \
    orders[orders['group'] == 'B']['visitorId'])
visitors_intersection
Out[14]:
array([   8300375,  199603092,  232979603,  237748145,  276558944,
        351125977,  393266494,  457167155,  471551937,  477780734,
        818047933,  963407295, 1230306981, 1294878855, 1316129916,
       1333886533, 1404934699, 1602967004, 1614305549, 1648269707,
       1668030113, 1738359350, 1801183820, 1959144690, 2038680547,
       2044997962, 2378935119, 2458001652, 2579882178, 2587333274,
       2600415354, 2654030115, 2686716486, 2712142231, 2716752286,
       2780786433, 2927087541, 2949041841, 2954449915, 3062433592,
       3202540741, 3234906277, 3656415546, 3717692402, 3766097110,
       3803269165, 3891541246, 3941795274, 3951559397, 3957174400,
       3963646447, 3972127743, 3984495233, 4069496402, 4120364173,
       4186807279, 4256040402, 4266935830])
In [15]:
len(visitors_intersection)
Out[15]:
58
In [16]:
len(visitors_intersection) / orders.shape[0]
Out[16]:
0.04845446950710108

Таких пользователей 58, что составляет менее 5% выборки. Следует ли исключать их из теста? 🤔

Например, пользователь 8300375 отметился сразу в двух группах.

In [17]:
orders[orders['visitorId'] == 8300375]
Out[17]:
transactionId visitorId date revenue group
71 3679129301 8300375 2019-08-01 10510 B
703 4293855558 8300375 2019-08-07 1790 A

А вот пересечений заказов нет.

In [18]:
len(np.intersect1d(orders[orders['group'] == 'A']['transactionId'], orders[orders['group'] == 'B']['transactionId']))
Out[18]:
0

Попарно сравним параметры каждой выборки по группам.

In [19]:
ordersAggregated = orders.groupby(
    ['date','group']).agg({'transactionId':'nunique', 'visitorId':'nunique', 'revenue':'sum'}).reset_index()
ordersAggregated[['transactionId_cum', 'visitorId_cum', 'revenue_cum']] = pd.concat(
    [ordersAggregated[ordersAggregated['group'] == 'A'][['transactionId', 'visitorId', 'revenue']].cumsum(), \
     ordersAggregated[ordersAggregated['group'] == 'B'][['transactionId', 'visitorId', 'revenue']].cumsum()])
In [20]:
visitorsAggregated = visitors.groupby(['date','group']).agg({'visitors':'sum'}).reset_index()
visitorsAggregated['visitors_cum'] = pd.concat(
    [visitorsAggregated[visitorsAggregated['group'] == 'A']['visitors'].cumsum(), \
     visitorsAggregated[visitorsAggregated['group'] == 'B']['visitors'].cumsum()])
In [21]:
plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('Гистограмма выручки заказа по группам')
plt.bar(ordersAggregated[ordersAggregated['group'] == 'A']['revenue'].index,
        ordersAggregated[ordersAggregated['group'] == 'A']['revenue'], label='A')
plt.bar(ordersAggregated[ordersAggregated['group'] == 'B']['revenue'].index,
        ordersAggregated[ordersAggregated['group'] == 'B']['revenue'], label='B')

ax = plt.gca()
ax.set_ylabel('Выручка заказа')
ax.get_xaxis().set_visible(False)
plt.legend();
In [22]:
plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('Гистограмма количества заказов по группам')
plt.bar(ordersAggregated[ordersAggregated['group'] == 'A']['transactionId'].index,
        ordersAggregated[ordersAggregated['group'] == 'A']['transactionId'], label='A')
plt.bar(ordersAggregated[ordersAggregated['group'] == 'B']['transactionId'].index,
        ordersAggregated[ordersAggregated['group'] == 'B']['transactionId'], label='B')

ax = plt.gca()
ax.set_ylabel('Количество заказов')
ax.get_xaxis().set_visible(False)
plt.legend();
In [23]:
plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('Гистограмма количества покупателей по группам')
plt.bar(ordersAggregated[ordersAggregated['group'] == 'A']['visitorId'].index,
        ordersAggregated[ordersAggregated['group'] == 'A']['visitorId'], label='A')
plt.bar(ordersAggregated[ordersAggregated['group'] == 'B']['visitorId'].index,
        ordersAggregated[ordersAggregated['group'] == 'B']['visitorId'], label='B')

ax = plt.gca()
ax.set_ylabel('Количество покупателей')
ax.get_xaxis().set_visible(False)
plt.legend();
In [24]:
plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('Гистограмма количества посетителей по группам')
plt.bar(visitorsAggregated[visitorsAggregated['group'] == 'A']['visitors'].index,
        visitorsAggregated[visitorsAggregated['group'] == 'A']['visitors'], label='A')
plt.bar(visitorsAggregated[visitorsAggregated['group'] == 'B']['visitors'].index,
        visitorsAggregated[visitorsAggregated['group'] == 'B']['visitors'], label='B')

ax = plt.gca()
ax.set_ylabel('Количество посетителей')
ax.get_xaxis().set_visible(False)
plt.legend();

Вывод: за исключением всплеска выручки группы B 19 августа группы соразмерны.

1. График кумулятивной выручки по группам

Соберём агрегированные кумулятивные по дням данные о заказах в несколько действий. Сгруппируем таблицу orders по дате и группе A/B-теста и посчитаем количество уникальных заказов, покупателей и суммарную выручку. Сбросим индексы методом reset_index().

In [25]:
ordersAggregated = orders.groupby(
    ['date','group']).agg({'transactionId':'nunique', 'visitorId':'nunique', 'revenue':'sum'}).reset_index()
ordersAggregated.head(10)
Out[25]:
date group transactionId visitorId revenue
0 2019-08-01 A 24 20 148579
1 2019-08-01 B 21 20 101217
2 2019-08-02 A 20 19 93822
3 2019-08-02 B 24 23 165531
4 2019-08-03 A 24 24 112473
5 2019-08-03 B 16 16 114248
6 2019-08-04 A 16 15 70825
7 2019-08-04 B 17 16 108571
8 2019-08-05 A 25 25 124218
9 2019-08-05 B 23 23 92428

Сконкатенируем по группам кумулятивные данные о заказах, покупателях и выручке методом pd.concat() и сохраним в столбцы transactionId_cum, visitorId_cum, revenue_cum соответственно.

In [26]:
ordersAggregated[['transactionId_cum', 'visitorId_cum', 'revenue_cum']] = pd.concat(
    [ordersAggregated[ordersAggregated['group'] == 'A'][['transactionId', 'visitorId', 'revenue']].cumsum(), \
     ordersAggregated[ordersAggregated['group'] == 'B'][['transactionId', 'visitorId', 'revenue']].cumsum()])
ordersAggregated.head(10)
Out[26]:
date group transactionId visitorId revenue transactionId_cum visitorId_cum revenue_cum
0 2019-08-01 A 24 20 148579 24 20 148579
1 2019-08-01 B 21 20 101217 21 20 101217
2 2019-08-02 A 20 19 93822 44 39 242401
3 2019-08-02 B 24 23 165531 45 43 266748
4 2019-08-03 A 24 24 112473 68 63 354874
5 2019-08-03 B 16 16 114248 61 59 380996
6 2019-08-04 A 16 15 70825 84 78 425699
7 2019-08-04 B 17 16 108571 78 75 489567
8 2019-08-05 A 25 25 124218 109 103 549917
9 2019-08-05 B 23 23 92428 101 98 581995

Аналогично с количеством пользователей.

In [27]:
visitorsAggregated = visitors.groupby(['date','group']).agg({'visitors':'sum'}).reset_index()
In [28]:
visitorsAggregated['visitors_cum'] = pd.concat(
    [visitorsAggregated[visitorsAggregated['group'] == 'A']['visitors'].cumsum(), \
     visitorsAggregated[visitorsAggregated['group'] == 'B']['visitors'].cumsum()])
visitorsAggregated.head(10)
Out[28]:
date group visitors visitors_cum
0 2019-08-01 A 719 719
1 2019-08-01 B 713 713
2 2019-08-02 A 619 1338
3 2019-08-02 B 581 1294
4 2019-08-03 A 507 1845
5 2019-08-03 B 509 1803
6 2019-08-04 A 717 2562
7 2019-08-04 B 770 2573
8 2019-08-05 A 756 3318
9 2019-08-05 B 707 3280

Объединим обе таблицы в одной с понятными названиями столбцов. Лишние столбцы удалим.

In [29]:
cumulativeData = ordersAggregated.merge(visitorsAggregated, left_on=['date', 'group'], right_on=['date', 'group'])
del cumulativeData['transactionId']
del cumulativeData['visitorId']
del cumulativeData['revenue']
del cumulativeData['visitors']
cumulativeData.columns = ['date', 'group', 'orders', 'buyers', 'revenue', 'visitors']

cumulativeData.head(10)
Out[29]:
date group orders buyers revenue visitors
0 2019-08-01 A 24 20 148579 719
1 2019-08-01 B 21 20 101217 713
2 2019-08-02 A 44 39 242401 1338
3 2019-08-02 B 45 43 266748 1294
4 2019-08-03 A 68 63 354874 1845
5 2019-08-03 B 61 59 380996 1803
6 2019-08-04 A 84 78 425699 2562
7 2019-08-04 B 78 75 489567 2573
8 2019-08-05 A 109 103 549917 3318
9 2019-08-05 B 101 98 581995 3280

Построим графики кумулятивной выручки по дням и группам A/B-тестирования.

In [30]:
# датафрейм с кумулятивным количеством заказов и кумулятивной выручкой по дням в группе А
cumulativeRevenueA = cumulativeData[cumulativeData['group']=='A'][['date','revenue', 'orders']]

# датафрейм с кумулятивным количеством заказов и кумулятивной выручкой по дням в группе B
cumulativeRevenueB = cumulativeData[cumulativeData['group']=='B'][['date','revenue', 'orders']]

plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('График кумулятивной выручки по дням и группам A/B-тестирования')

# строим график выручки группы А
plt.plot(cumulativeRevenueA['date'], cumulativeRevenueA['revenue'], label='A')

# строим график выручки группы B
plt.plot(cumulativeRevenueB['date'], cumulativeRevenueB['revenue'], label='B')

ax = plt.gca()
ax.minorticks_on()
ax.grid(which='major', color = 'k')
ax.grid(which='minor')
ax.set_xlabel('Дни заказов')
ax.set_ylabel('Выручка заказов')

plt.legend();
/opt/conda/lib/python3.7/site-packages/pandas/plotting/_matplotlib/converter.py:103: FutureWarning:

Using an implicitly registered datetime converter for a matplotlib plotting method. The converter was registered by pandas on import. Future versions of pandas will require you to explicitly register matplotlib converters.

To register the converters:
	>>> from pandas.plotting import register_matplotlib_converters
	>>> register_matplotlib_converters()

График кумулятивной выручки группы B резко растёт 19-го августа, что свидетельствует о всплеске числа заказов, либо о появлении очень дорогих заказов в выборке.

2. График кумулятивного среднего чека по группам

Построим графики среднего чека по группам — разделим кумулятивную выручку на кумулятивное число заказов.

In [31]:
plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('График среднего чека по дням и группам A/B-тестирования')

plt.plot(cumulativeRevenueA['date'], cumulativeRevenueA['revenue']/cumulativeRevenueA['orders'], label='A')
plt.plot(cumulativeRevenueB['date'], cumulativeRevenueB['revenue']/cumulativeRevenueB['orders'], label='B')

ax = plt.gca()
ax.minorticks_on()
ax.grid(which='major', color = 'k')
ax.grid(which='minor')
ax.set_xlabel('Дни заказов')
ax.set_ylabel('Средний чек заказов')

plt.legend();

Средний чек заказов группы A вначале проседает, потом быстро растёт со всплеском 13 августа, затем стабилизируется. Средний чек группы B растёт скачкообразно (всплеск 19-го августа на месте), затем медленно падает.

3. График относительного изменения кумулятивного среднего чека группы B к группе A

Построим график относительного изменения кумулятивного среднего чека. Добавим горизонтальную ось методом axhline() (horizontal line across the axis — «горизонтальная линия поперек оси»).

In [32]:
# собираем данные в одном датафрейме
mergedCumulativeRevenue = cumulativeRevenueA.merge(
    cumulativeRevenueB, left_on='date', right_on='date', how='left', suffixes=['A', 'B'])

# cтроим отношение средних чеков
plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('График относительного изменения кумулятивного среднего чека группы B к группе A')
plt.plot(mergedCumulativeRevenue['date'], \
         (mergedCumulativeRevenue['revenueB']/mergedCumulativeRevenue['ordersB'])/ \
         (mergedCumulativeRevenue['revenueA']/mergedCumulativeRevenue['ordersA'])-1)

ax = plt.gca()
ax.minorticks_on()
ax.grid(which='major', color = 'k')
ax.grid(which='minor')
ax.set_xlabel('Дни заказов')
ax.set_ylabel('Относительное изменение кумулятивного среднего чека')

# добавляем ось X
plt.axhline(y=0, color='black', linestyle='--', linewidth = 3);

Резкие различия в кумулятивном среднем чеке наблюдаются 4-го, 6-го и 8-го, 19-го августа.

4. График кумулятивной конверсии по группам

Построим график кумулятивной конверсии по группам.

In [33]:
# считаем кумулятивную конверсию
cumulativeData['conversion'] = cumulativeData['orders']/cumulativeData['visitors']

# отделяем данные по группе A
cumulativeDataA = cumulativeData[cumulativeData['group']=='A']

# отделяем данные по группе B
cumulativeDataB = cumulativeData[cumulativeData['group']=='B']

# строим графики
plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('График кумулятивной конверсии по группам')

plt.plot(cumulativeDataA['date'], cumulativeDataA['conversion'], label='A')
plt.plot(cumulativeDataB['date'], cumulativeDataB['conversion'], label='B')

ax = plt.gca()
ax.minorticks_on()
ax.grid(which='major', color = 'k')
ax.grid(which='minor')
ax.set_xlabel('Дни заказов')
ax.set_ylabel('Кумулятивная конверсия')
plt.legend();

Графики кумулятивной конверсии неплавные, есть выбросы. Кумулятивная конверсия группы B на 10-15% выше конверсии группы A.

5. График относительного изменения кумулятивной конверсии группы B к группе A

Построим график относительного различия кумулятивных конверсий.

In [34]:
mergedCumulativeConversions = cumulativeDataA[['date','conversion']].merge(
    cumulativeDataB[['date','conversion']], left_on='date', right_on='date', how='left', suffixes=['A', 'B'])

plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('График относительного различия кумулятивных конверсий')

plt.plot(mergedCumulativeConversions['date'], \
         mergedCumulativeConversions['conversionB']/mergedCumulativeConversions['conversionA']-1, \
         label="Относительный прирост конверсии группы B относительно группы A")

plt.axhline(y=0, color='black', linestyle='--', linewidth = 3)
plt.axhline(y=0.125, color='grey', linestyle='--', linewidth = 3)

ax = plt.gca()
ax.minorticks_on()
ax.grid(which='major', color = 'k')
ax.grid(which='minor')
ax.set_xlabel('Дни заказов')
ax.set_ylabel('Относительное различие кумулятивных конверсий')
plt.legend();

В начале теста группа B просела относительно группы A, затем резко выросла и стабилизировалась.

6. Точечный график количества заказов по пользователям

Подсчитаем количество заказов по пользователям и посмотрим на результат.

In [35]:
ordersByUsers = orders.groupby('visitorId', as_index=False).agg({'transactionId' : pd.Series.nunique})
ordersByUsers.columns = ['visitorId','orders']
ordersByUsers.head(10)
Out[35]:
visitorId orders
0 5114589 1
1 6958315 1
2 8300375 2
3 11685486 1
4 39475350 1
5 47206413 1
6 48147722 1
7 54447517 1
8 56960363 1
9 66685450 1

Проанализируем количество заказов по пользователям методом describe().

In [36]:
ordersByUsers['orders'].describe()
Out[36]:
count    1031.000000
mean        1.161009
std         0.724919
min         1.000000
25%         1.000000
50%         1.000000
75%         1.000000
max        11.000000
Name: orders, dtype: float64

Построим гистрограмму распределения количества заказов на одного пользователя.

In [37]:
ordersByUsers['orders'].hist(figsize=(12, 7), bins=11)
ax = plt.gca()
ax.set_xlabel('Заказы, в шт.')
ax.set_ylabel('Количество пользователей')
plt.title('Гистограмма распределения количества заказов на одного пользователя');

Большинство покупателей заказывали только один раз. Однако есть пользователи с 2-5 заказами. Построим точечную диаграмму числа заказов на одного пользователя.

In [38]:
x_values = pd.Series(range(0,len(ordersByUsers)))

plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('Точечная диаграмма числа заказов на одного пользователя')
ax = plt.gca()
ax.set_xlabel('Пользователи')
ax.set_ylabel('Количество заказов')
plt.scatter(x_values, ordersByUsers['orders']);

Есть пользователи с 2-5 заказами.

7. 95-й и 99-й перцентили количества заказов на пользователя. Граница для определения аномальных пользователей

Определим 95 и 99 перцентили количества заказов на одного пользователя методом percentile() библиотеки Numpy.

In [39]:
np.percentile(ordersByUsers['orders'], [95, 99])
Out[39]:
array([2., 4.])

Не более 5% пользователей оформляли больше двух заказов. И 1% пользователей заказывал более четырёх раз. Примем 3 заказа на одного пользователя за нижнюю границу числа заказов.

8. Точечный график стоимостей заказов

Проанализируем распределение стоимостей заказов методом describe().

In [40]:
orders['revenue'].describe()
Out[40]:
count    1.197000e+03
mean     8.348006e+03
std      3.919113e+04
min      5.000000e+01
25%      1.220000e+03
50%      2.978000e+03
75%      8.290000e+03
max      1.294500e+06
Name: revenue, dtype: float64

Построим гистограмму, ограничив верхнюю планку заказа 40 000.

In [41]:
orders['revenue'].hist(range=(1, 40000), figsize=(12, 7), bins=100)
ax = plt.gca()
ax.set_xlabel('Стоимость (до 40 000)')
ax.set_ylabel('Количество заказов')
plt.title('Гистограмма распределения стоимостей заказов');

Построим точечную диаграмму стоимостей заказов, ограничив её значение до 100 000.

In [42]:
x_values = pd.Series(range(0,len(orders['revenue'])))

plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('Точечная диаграмма стоимостей заказов')
ax = plt.gca()
ax.set_xlabel('Пользователи')
ax.set_ylabel('Стоимость заказа (до 100 000)')
plt.ylim(0, 100000)
plt.scatter(x_values, orders['revenue']);

Разброс стоимостей заказов значительный, определить границу аномальных заказов затруднительно.

9. 95-й и 99-й перцентили стоимости заказов. Граница для определения аномальных заказов

Определим 95 и 99 перцентили стоисмостей заказа.

In [43]:
np.percentile(orders['revenue'], [95, 99])
Out[43]:
array([28000. , 58233.2])

Не более 5% заказов дороже 28 000 рублей и не более 1% дороже 58 233. Примем 30 000 за нижнюю границу стоимости заказа.

Отличная работа
Аномалии выявлены верно. Молодец, что дополнительно использовать гистограммы👍

10. Статистическая значимость различий в конверсии между группами по «сырым» данным

Подготовим итоговую таблицу data.

In [44]:
visitorsADaily = visitors[visitors['group']=='A'][['date', 'visitors']]
visitorsADaily.columns = ['date', 'visitorsPerDateA']

visitorsACummulative = visitorsAggregated[visitorsAggregated['group'] == 'A'][['date', 'visitors_cum']]
visitorsACummulative.columns = ['date', 'visitorsCummulativeA']
# visitorsACummulative
In [45]:
visitorsBDaily = visitors[visitors['group']=='B'][['date', 'visitors']]
visitorsBDaily.columns = ['date', 'visitorsPerDateB']

visitorsBCummulative = visitorsAggregated[visitorsAggregated['group'] == 'B'][['date', 'visitors_cum']]
visitorsBCummulative.columns = ['date', 'visitorsCummulativeB']
# visitorsBCummulative
In [46]:
ordersADaily = orders[orders['group']=='A'][['date', 'transactionId', 'visitorId', 'revenue']]\
    .groupby('date', as_index=False)\
    .agg({'transactionId' : pd.Series.nunique, 'revenue' : 'sum'})
ordersADaily.columns = ['date', 'ordersPerDateA', 'revenuePerDateA']

ordersACummulative = ordersAggregated[ordersAggregated['group'] == 'A'][['date', 'transactionId_cum', 'revenue_cum']]
ordersACummulative.columns = ['date', 'ordersCummulativeA', 'revenueCummulativeA']
# ordersACummulative
In [47]:
ordersBDaily = orders[orders['group']=='B'][['date', 'transactionId', 'visitorId', 'revenue']]\
    .groupby('date', as_index=False)\
    .agg({'transactionId' : pd.Series.nunique, 'revenue' : 'sum'})
ordersBDaily.columns = ['date', 'ordersPerDateB', 'revenuePerDateB']

ordersBCummulative = ordersAggregated[ordersAggregated['group'] == 'B'][['date', 'transactionId_cum', 'revenue_cum']]
ordersBCummulative.columns = ['date', 'ordersCummulativeB', 'revenueCummulativeB']
# ordersBCummulative
In [48]:
data = ordersADaily.merge(ordersBDaily, left_on='date', right_on='date', how='left')\
    .merge(ordersACummulative, left_on='date', right_on='date', how='left')\
    .merge(ordersBCummulative, left_on='date', right_on='date', how='left')\
    .merge(visitorsADaily, left_on='date', right_on='date', how='left')\
    .merge(visitorsBDaily, left_on='date', right_on='date', how='left')\
    .merge(visitorsACummulative, left_on='date', right_on='date', how='left')\
    .merge(visitorsBCummulative, left_on='date', right_on='date', how='left')
data.head()
Out[48]:
date ordersPerDateA revenuePerDateA ordersPerDateB revenuePerDateB ordersCummulativeA revenueCummulativeA ordersCummulativeB revenueCummulativeB visitorsPerDateA visitorsPerDateB visitorsCummulativeA visitorsCummulativeB
0 2019-08-01 24 148579 21 101217 24 148579 21 101217 719 713 719 713
1 2019-08-02 20 93822 24 165531 44 242401 45 266748 619 581 1338 1294
2 2019-08-03 24 112473 16 114248 68 354874 61 380996 507 509 1845 1803
3 2019-08-04 16 70825 17 108571 84 425699 78 489567 717 770 2562 2573
4 2019-08-05 25 124218 23 92428 109 549917 101 581995 756 707 3318 3280

Названия столбцов в таблице:

  • date — дата;
  • ordersPerDateA — количество заказов в выбранную дату в группе A;
  • revenuePerDateA — суммарная выручка в выбранную дату в группе A;
  • ordersPerDateB — количество заказов в выбранную дату в группе B;
  • revenuePerDateB — суммарная выручка в выбранную дату в группе B;
  • ordersCummulativeA — суммарное число заказов до выбранной даты включительно в группе A;
  • revenueCummulativeA — суммарная выручка до выбранной даты включительно в группе A;
  • ordersCummulativeB — суммарное количество заказов до выбранной даты включительно в группе B;
  • revenueCummulativeB — суммарная выручка до выбранной даты включительно в группе B;
  • visitorsPerDateA — количество пользователей в выбранную дату в группе A;
  • visitorsPerDateB — количество пользователей в выбранную дату в группе B;
  • visitorsCummulativeA — количество пользователей до выбранной даты включительно в группе A;
  • visitorsCummulativeB — количество пользователей до выбранной даты включительно в группе B.

Посчитаем статистическую значимость различия в конверсии между группами. Создадим переменные ordersByUsersA и ordersByUsersB со столбцами ['userId', 'orders']. В них для пользователей, которые заказывали хотя бы 1 раз, укажем число совершённых заказов.

In [49]:
ordersByUsersA = orders[orders['group']=='A'].groupby('visitorId', as_index=False).agg({'transactionId' : pd.Series.nunique})
ordersByUsersA.columns = ['visitorId', 'orders']

ordersByUsersB = orders[orders['group']=='B'].groupby('visitorId', as_index=False).agg({'transactionId' : pd.Series.nunique})
ordersByUsersB.columns = ['visitorId', 'orders']
ordersByUsersA
Out[49]:
visitorId orders
0 8300375 1
1 11685486 1
2 54447517 1
3 66685450 1
4 78758296 1
... ... ...
498 4243832526 1
499 4256040402 7
500 4259830713 1
501 4266935830 1
502 4278982564 1

503 rows × 2 columns

Для подготовки выборок к проверке критерием Манна-Уитни объявим переменные sampleA и sampleB, в которых пользователям из разных групп будет соответствовать количество заказов. Тем, кто ничего не заказал, будут соответствовать нули.

Переменная sampleA состоит из двух частей:

  1. Список с количеством заказов для каждого из пользователей: ordersByUsersA['orders'].
  2. Нули для пользователей, которые ничего не заказывали. Их количество равно разнице между суммой посетителей и количеством записей о заказах: data['visitorsPerDateA'].sum() - len(ordersByUsersA['orders']). Создадим объект pd.Series нужной длины: pd.Series(0, index=np.arange(data['visitorsPerDateA'].sum() - len(ordersByUsersA['orders'])), name='orders')

Создадим список индексов функцией np.arange(), создающей массив индексов в формате np.array, который требуется для pd.Series. Объединим последовательности функцией pd.concat() с добавлением параметра axis=0 — по строкам.

In [50]:
sampleA = pd.concat(
    [ordersByUsersA['orders'],pd.Series(0, index=np.arange(data['visitorsPerDateA'].sum() - len(ordersByUsersA['orders'])), \
                                        name='orders')], axis=0)

sampleB = pd.concat(
    [ordersByUsersB['orders'],pd.Series(0, index=np.arange(data['visitorsPerDateB'].sum() - len(ordersByUsersB['orders'])), \
                                        name='orders')], axis=0)
In [62]:
sampleA
Out[62]:
0        1
1        1
2        1
3        1
4        1
        ..
18228    0
18229    0
18230    0
18231    0
18232    0
Name: orders, Length: 18736, dtype: int64

Сформулируем следующие гипотезы.

Нулевая гипотеза: конверсии групп A и B по «сырым» данным совпадают.

Альтернативная гипотеза: конверсии групп A и B по «сырым» данным различаются.

Применим критерий и отформатируем p-value, округлив его до трёх знаков после запятой. Выведем относительный прирост конверсии группы B: конверсия группы B / конверсия группы A - 1. Округлим до трёх знаков после запятой.

In [51]:
alpha = .05 # критический уровень статистической значимости

results = st.mannwhitneyu(sampleA, sampleB)
print('p-значение: ', "{0:.3f}".format(results.pvalue))

if (results.pvalue < alpha):
    print("Отвергаем нулевую гипотезу: разница статистически значима")
else:
    print("Не получилось отвергнуть нулевую гипотезу, вывод о различии сделать нельзя")

print("{0:.3f}".format(sampleB.mean()/sampleA.mean()-1))
p-значение:  0.008
Отвергаем нулевую гипотезу: разница статистически значима
0.138

По «сырым» данным в конверсии между группами A и B есть статистически значимое различие. Конверсия группы B на 14% выше.

11. Статистическая значимость различий в среднем чеке заказа между группами по «сырым» данным

Сформулируем следующие гипотезы.

Нулевая гипотеза: средний чек заказа групп A и B по «сырым» данным совпадает.

Альтернативная гипотеза: средний чек заказа групп A и B по «сырым» данным различается.

Рассчитаем статистическую значимость различий в среднем чеке между сегментами, передадав критерию mannwhitneyu() данные о выручке с заказов. Найдём относительные различия в среднем чеке между группами.

In [52]:
results = st.mannwhitneyu(orders[orders['group']=='A']['revenue'], orders[orders['group']=='B']['revenue'])

print('p-значение: ', "{0:.3f}".format(results.pvalue))

if (results.pvalue < alpha):
    print("Отвергаем нулевую гипотезу: разница статистически значима")
else:
    print("Не получилось отвергнуть нулевую гипотезу, вывод о различии сделать нельзя")

print("{0:.3f}".format(orders[orders['group']=='B']['revenue'].mean()/orders[orders['group']=='A']['revenue'].mean()-1))
p-значение:  0.365
Не получилось отвергнуть нулевую гипотезу, вывод о различии сделать нельзя
0.259

По «сырым» данным различий в среднем чеке заказа между группами A и B нет. Средний чек группы B на четверть выше среднего чека группы A.

12. Статистическая значимость различий в конверсии между группами по «очищенным» данным

Сделаем срезы пользователей с числом заказов больше 3 — usersWithManyOrders и пользователей, совершивших заказы дороже 30 000 — usersWithExpensiveOrders. Объединим их в таблице abnormalUsers. Выведем их количество.

In [53]:
usersWithManyOrders = pd.concat(
    [ordersByUsersA[ordersByUsersA['orders'] > 3]['visitorId'], \
     ordersByUsersB[ordersByUsersB['orders'] > 3]['visitorId']], axis = 0)
usersWithExpensiveOrders = orders[orders['revenue'] > 30000]['visitorId']
abnormalUsers = pd.concat([usersWithManyOrders, usersWithExpensiveOrders], axis = 0).drop_duplicates().sort_values()
abnormalUsers.shape
Out[53]:
(57,)

Всего 57 аномальных пользователей.

Узнаем, как их действия повлияли на результаты теста. Посчитаем статистическую значимость различий в конверсии между группами теста по «очищенным» данным. Сначала подготовим выборки количества заказов по пользователям по группам теста.

In [54]:
sampleAFiltered = pd.concat(
    [ordersByUsersA[np.logical_not(ordersByUsersA['visitorId'].isin(abnormalUsers))]['orders'], \
     pd.Series(0, index=np.arange(data['visitorsPerDateA'].sum() - len(ordersByUsersA['orders'])),name='orders')],axis=0)

sampleBFiltered = pd.concat(
    [ordersByUsersB[np.logical_not(ordersByUsersB['visitorId'].isin(abnormalUsers))]['orders'], \
     pd.Series(0, index=np.arange(data['visitorsPerDateB'].sum() - len(ordersByUsersB['orders'])),name='orders')],axis=0)

Сформулируем следующие гипотезы.

Нулевая гипотеза: конверсии групп A и B по «очищенным» данным совпадают.

Альтернативная гипотеза: конверсии групп A и B по «очищенным» данным различаются.

Применим статистический критерий Манна-Уитни к полученным выборкам.

In [55]:
results = st.mannwhitneyu(sampleAFiltered, sampleBFiltered)

print('p-значение: ', "{0:.3f}".format(results.pvalue))

if (results.pvalue < alpha):
    print("Отвергаем нулевую гипотезу: разница статистически значима")
else:
    print("Не получилось отвергнуть нулевую гипотезу, вывод о различии сделать нельзя")

print("{0:.3f}".format(sampleBFiltered.mean()/sampleAFiltered.mean()-1))
p-значение:  0.009
Отвергаем нулевую гипотезу: разница статистически значима
0.148

Результаты по конверсии практически не изменились. Группа B по-прежнему лучше группы A.

13. Статистическая значимость различий в среднем чеке заказа между группами по «очищенным» данным

Сформулируем следующие гипотезы.

Нулевая гипотеза: средний чек заказа групп A и B по «очищенным» данным совпадает.

Альтернативная гипотеза: средний чек заказа групп A и B по «очищенным» данным различается.

Посчитаем статистическую значимость различий в среднем чеке заказа между группами по очищенным данным.

In [56]:
results = st.mannwhitneyu(
    orders[np.logical_and(
        orders['group']=='A',
        np.logical_not(orders['visitorId'].isin(abnormalUsers)))]['revenue'],
    orders[np.logical_and(
        orders['group']=='B',
        np.logical_not(orders['visitorId'].isin(abnormalUsers)))]['revenue'])

print('p-значение: ', "{0:.3f}".format(results.pvalue))

if (results.pvalue < alpha):
    print("Отвергаем нулевую гипотезу: разница статистически значима")
else:
    print("Не получилось отвергнуть нулевую гипотезу, вывод о различии сделать нельзя")

print("{0:.3f}".format(
    orders[np.logical_and(orders['group']=='B',np.logical_not(orders['visitorId'].isin(abnormalUsers)))]['revenue'].mean()/
    orders[np.logical_and(
        orders['group']=='A',
        np.logical_not(orders['visitorId'].isin(abnormalUsers)))]['revenue'].mean() - 1))
p-значение:  0.479
Не получилось отвергнуть нулевую гипотезу, вывод о различии сделать нельзя
-0.020
In [ ]:
 

P-value увеличился, разница в среднем чеке стала почти нулевой.

14. Решение по результатам теста

Сравним кумулятивный среднего чека по группам по «очищенным» данным. Сохраним кумулятивные данные о заказах в таблицах ordersAFiltered и ordersBFiltered.

In [57]:
ordersAFiltered = orders[np.logical_and(
        orders['group']=='A',
        np.logical_not(orders['visitorId'].isin(abnormalUsers)))][['date', 'transactionId', 'revenue']].groupby(
    ['date']).agg({'transactionId':'nunique', 'revenue':'sum'}).cumsum().reset_index()

ordersBFiltered = orders[np.logical_and(
        orders['group']=='B',
        np.logical_not(orders['visitorId'].isin(abnormalUsers)))][['date', 'transactionId', 'revenue']].groupby(
    ['date']).agg({'transactionId':'nunique', 'revenue':'sum'}).cumsum().reset_index()
In [58]:
ordersAFiltered.head()
Out[58]:
date transactionId revenue
0 2019-08-01 23 142779
1 2019-08-02 43 236601
2 2019-08-03 67 349074
3 2019-08-04 80 415489
4 2019-08-05 105 539707

Построим график.

In [59]:
plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('График среднего чека по очищенным данным по дням и группам A/B-тестирования')

plt.plot(ordersAFiltered['date'], ordersAFiltered['revenue'] / ordersAFiltered['transactionId'], label='A')
plt.plot(ordersBFiltered['date'], ordersBFiltered['revenue'] / ordersBFiltered['transactionId'], label='B')

ax = plt.gca()
ax.minorticks_on()
ax.grid(which='major', color = 'k')
ax.grid(which='minor')
ax.set_xlabel('Дни заказов')
ax.set_ylabel('Средний чек заказов')

plt.legend();

Построим график относительного изменения кумулятивного среднего чека по «очищенным» данным.

In [60]:
# собираем данные в одном датафрейме
mergedCumulativeRevenueFiltered = ordersAFiltered.merge(
    ordersBFiltered, left_on='date', right_on='date', how='left', suffixes=['A', 'B'])
mergedCumulativeRevenueFiltered.tail()
Out[60]:
date transactionIdA revenueA transactionIdB revenueB
26 2019-08-27 443 2295884 497 2616651
27 2019-08-28 460 2397006 522 2686889
28 2019-08-29 477 2518191 539 2805619
29 2019-08-30 488 2604651 560 2906132
30 2019-08-31 499 2661314 578 3021911
In [61]:
# cтроим отношение средних чеков
plt.figure(figsize=(12, 7))
plt.grid(True)
plt.title('График относительного изменения кумулятивного среднего чека группы B к группе A по очищенным данным')
plt.plot(mergedCumulativeRevenueFiltered['date'], \
         (mergedCumulativeRevenueFiltered['revenueB']/mergedCumulativeRevenueFiltered['transactionIdB'])/ \
         (mergedCumulativeRevenueFiltered['revenueA']/mergedCumulativeRevenueFiltered['transactionIdA'])-1)

ax = plt.gca()
ax.minorticks_on()
ax.grid(which='major', color = 'k')
ax.grid(which='minor')
ax.set_xlabel('Дни заказов')
ax.set_ylabel('Относительное изменение кумулятивного среднего чека')

# добавляем ось X
plt.axhline(y=0, color='black', linestyle='--', linewidth = 3);

Есть статистически значимое различие по конверсии между группами как по «сырым» данным, так и по «очищенным». График различия конверсии между группами показывает, что результаты группы B лучше группы A.

Нет статистически значимого различия по среднему чеку между группами ни по «сырым» данным, ни по «очищенным». После удаления аномалий средний чек группы B стал незначительно ниже группы A.

Выводы

Исходя из обнаруженных фактов, тест следует остановить и признать его неуспешным. Несмотря на рост конверсии, средний чек незначительно снизился.

Есть статистически значимое различие по конверсии между группами как по «сырым» данным, так и по «очищенным». График различия конверсии между группами показывает, что результаты группы B лучше группы A.

Нет статистически значимого различия по среднему чеку между группами ни по «сырым» данным, ни по «очищенным». После удаления аномалий средний чек группы B стал незначительно ниже группы A.

График кумулятивной выручки группы B резко растёт 19-го августа, что свидетельствует о всплеске числа заказов, либо о появлении очень дорогих заказов в выборке.

Средний чек заказов группы A вначале проседает, затем быстро растёт со всплеском 13 августа, затем стабилизируется. Средний чек группы B растёт скачкообразно (всплеск 19-го августа на месте), затем медленно падает.

Резкие различия в кумулятивном среднем чеке наблюдаются 4-го, 6-го и 8-го, 19-го августа.

Графики кумулятивной конверсии неплавные, есть выбросы. Кумулятивная конверсия группы B на 10-15% выше конверсии группы A.

В начале теста группа B просела относительно группы A, затем резко выросла и стабилизировалась.

Большинство покупателей заказывали только один раз, но есть пользователи с 2-5 заказами и выше. Не более 5% пользователей оформляли больше двух заказов. И 1% пользователей заказывал более четырёх раз. Примем 3 заказа на одного пользователя за нижнюю границу числа заказов.

Не более 5% заказов дороже 28 000 рублей и не более 1% дороже 58 233. Примем 30 000 за нижнюю границу стоимости заказа.

По «сырым» данным в конверсии между группами A и B есть статистически значимое различие. Конверсия группы B на 14% выше конверсии группы A. По «очищенным» данным результаты по конверсии практически не изменились. Группа B по-прежнему лучше группы A.

По «сырым» данным различий в среднем чеке заказа между группами A и B нет. Средний чек группы B на четверть выше среднего чека группы A. По «очищенным» данным p-value увеличился, разница в среднем чеке стала почти нулевой.